Skip to content

Fix focused notification sound playback - #1855

Merged
lawrencecchen merged 3 commits into
mainfrom
task-custom-sound-notification-reliability
Mar 20, 2026
Merged

lawrencecchen merged 3 commits into
mainfrom
task-custom-sound-notification-reliability

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • play notification sounds locally when external delivery is suppressed for the focused terminal
  • retain custom file NSSound instances during playback so focused custom sounds stay reliable

Testing

  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination 'platform=macOS' -derivedDataPath /tmp/cmux-task-custom-sound-notification-reliability-tests test -only-testing:cmuxTests/NotificationDockBadgeTests/testFocusedTerminalNotificationStillRunsLocalSoundFeedbackWhenExternalDeliveryIsSuppressed (red on commit 3f1e9bfb, green on commit dc00cf3c)
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination 'platform=macOS' -derivedDataPath /tmp/cmux-task-custom-sound-notification-reliability-tests test -only-testing:cmuxTests/NotificationDockBadgeTests

Issues

  • Related: custom notification sounds do not play while cmux is focused, including /Users/lawrence/Documents/dune-scream.mp3

Summary by cubic

Fixes focused notification feedback by playing the selected sound locally and running the custom command when external delivery is suppressed. Also improves custom sound reliability by retaining NSSound during playback.

  • Bug Fixes
    • Play local notification sound and run the custom command when the terminal is focused and notifications are suppressed.
    • Retain custom NSSound instances and release via delegate after playback to prevent early deallocation and missed sounds.

Written for commit 56c031f. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • When external notification delivery is suppressed (e.g., app in focus), the app now provides local feedback: plays the configured notification sound and can run a configured custom command using the resolved notification title.
  • Bug Fixes
    • More reliable sound playback lifecycle and unified handling for selected vs. preview sounds.
  • Tests
    • Added tests and test hooks validating local sound feedback and custom-command execution when delivery is suppressed.

@vercel

vercel Bot commented Mar 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 20, 2026 8:03am

@coderabbitai

coderabbitai Bot commented Mar 20, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 53011cdb-95c3-4c2c-ba38-30ed11656eb2

📥 Commits

Reviewing files that changed from the base of the PR and between dc00cf3 and 56c031f.

📒 Files selected for processing (2)
  • Sources/TerminalNotificationStore.swift
  • cmuxTests/NotificationAndMenuBarTests.swift

📝 Walkthrough

Walkthrough

Notification sound playback now retains NSSound instances until playback completes, centralizes selected vs preview playback, and routes suppressed external-delivery notifications to a feedback handler that runs the selected sound and optional custom command. Test hooks were added to exercise suppressed-feedback behavior.

Changes

Cohort / File(s) Summary
Sound management & suppressed-delivery
Sources/TerminalNotificationStore.swift
Retain playing NSSound instances in a thread-safe map and release them via a shared NSSoundDelegate. Added playSelectedSound(defaults:) and centralized playback via a new private playSound(value:defaults:). Changed suppressed external-delivery path to invoke a suppressedNotificationFeedbackHandler (default runs selected sound + custom command) and added DEBUG-only configure/reset hooks. Also refactored title resolution into resolvedNotificationTitle(for:).
Notification tests
cmuxTests/NotificationAndMenuBarTests.swift
Reset new test-only hooks in teardown. Added tests verifying that suppressed notifications targeting the focused terminal produce local sound feedback and run the configured custom command (asserts on sound/command side effects and absence of external delivery).

Sequence Diagram(s)

sequenceDiagram
    participant Store as TerminalNotificationStore
    participant Defaults as UserDefaults / NotificationSoundSettings
    participant Sound as NSSound (system)
    participant Cmd as Shell / Custom Command

    Note over Store,Defaults: Notification arrives for terminal
    Store->>Store: determine shouldSuppressExternalDelivery
    alt suppressed
        Store->>Store: resolvedNotificationTitle(for:)
        Store->>Defaults: request selected sound / command
        Defaults->>Sound: playSelectedSound (via playSound)
        Sound-->>Store: playback finished (delegate) 
        Store->>Cmd: run configured custom command (with resolved title)
    else deliver externally
        Store->>SystemNotificationCenter: schedule user notification
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I keep the ding alive, hop-hop, no fright,
When delivery sleeps I sing into the night.
Sounds held gently till the last bright ring,
Commands that tap and files that sing.
Hop, a tiny rabbit cheer — ding! 🔔

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing focused notification sound playback, which is the central issue addressed by the PR.
Description check ✅ Passed The description includes summary and testing sections with specific details. However, it lacks demo video URL, and the checklist items are not explicitly checked off.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-custom-sound-notification-reliability
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

@greptile-apps

greptile-apps Bot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two related bugs in focused-terminal notification handling: (1) it corrects a logical inversion (if !shouldSuppressExternalDelivery) that previously swallowed all notification actions when the app was focused on the originating terminal, and (2) it introduces an activePlaybackSounds retention map so that NSSound instances created for custom file playback are kept alive until the NSSoundDelegate didFinishPlaying callback fires — preventing sounds from being silently cut short.

Key changes:

  • addNotification now routes to a new suppressedNotificationFeedbackHandler when external delivery is suppressed, instead of doing nothing
  • playSuppressedNotificationFeedback calls NotificationSoundSettings.playSelectedSound() to give local audio feedback in the focused case
  • playSoundFile(at:) retains the NSSound instance in a static activePlaybackSounds dictionary via a shared NSSoundDelegate, releasing it only after playback completes
  • previewSound is refactored to share a common playSound private method with the new playSelectedSound entry point
  • A new integration test verifies that suppressed notifications trigger local feedback handlers rather than external delivery handlers

Concern: playSuppressedNotificationFeedback discards the notification parameter (_ = notification) and never calls NotificationSoundSettings.runCustomCommand(...). scheduleUserNotification always fires this hook after scheduling; skipping it in the suppressed path means users with a custom notification command configured will never see it run when cmux is focused on the terminal that triggered the notification.

Confidence Score: 3/5

  • Safe to merge for the sound-retention fix, but the missing runCustomCommand call in the suppressed path is a behavioral regression for users with custom notification commands configured.
  • The core logic inversion fix and NSSound retention mechanism are both correct and well-tested. The new test provides good coverage for the suppressed-delivery path. Score is lowered because playSuppressedNotificationFeedback silently skips the runCustomCommand hook that the normal delivery path always fires — this is a real omission for users who rely on custom notification commands.
  • Sources/TerminalNotificationStore.swift — specifically the playSuppressedNotificationFeedback method

Important Files Changed

Filename Overview
Sources/TerminalNotificationStore.swift Core fix is correct — logical inversion in addNotification and NSSound retention both look sound. However, playSuppressedNotificationFeedback omits the runCustomCommand call that scheduleUserNotification always fires, silently breaking the custom-command hook for focused-window notifications.
cmuxTests/NotificationAndMenuBarTests.swift New test correctly exercises the suppressed-delivery path with injectable handlers and proper tearDown cleanup. Minor fragility: the AppDelegate.shared ?? AppDelegate() fallback creates a disconnected instance if the shared delegate is nil, which would cause test assertions to fail rather than pass incorrectly.

Sequence Diagram

sequenceDiagram
    participant T as Terminal
    participant NS as TerminalNotificationStore
    participant AF as AppFocusState
    participant SFH as suppressedFeedbackHandler
    participant NDH as notificationDeliveryHandler
    participant NSS as NotificationSoundSettings
    participant UN as UNNotificationCenter

    T->>NS: addNotification(tabId, surfaceId, ...)
    NS->>AF: isAppFocused() + isFocusedPanel?
    AF-->>NS: shouldSuppressExternalDelivery

    alt shouldSuppressExternalDelivery == true (app focused on that terminal)
        NS->>SFH: suppressedNotificationFeedbackHandler(store, notification)
        SFH->>NSS: playSelectedSound()
        NSS->>NSS: playSound(value, defaults)
        Note over NSS: retainActivePlaybackSound(sound)<br/>sound.delegate = activePlaybackSoundDelegate<br/>sound.play()
        Note right of SFH: ⚠️ runCustomCommand NOT called here
    else shouldSuppressExternalDelivery == false
        NS->>NDH: notificationDeliveryHandler(store, notification)
        NDH->>NS: scheduleUserNotification(notification)
        NS->>UN: center.add(request)
        UN-->>NS: success callback
        NS->>NSS: runCustomCommand(title, subtitle, body)
    end
Loading

Last reviewed commit: "fix: play notificati..."

Comment thread Sources/TerminalNotificationStore.swift
Comment thread cmuxTests/NotificationAndMenuBarTests.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
cmuxTests/NotificationAndMenuBarTests.swift (1)

448-450: Optional: tighten assertion to verify exact notification identity.

You currently assert count-only for local feedback. Consider asserting the callback carries the same notification ID produced by addNotification to make the test more precise.

Possible assertion strengthening
         XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
         XCTAssertTrue(deliveredNotificationIDs.isEmpty)
         XCTAssertEqual(localFeedbackNotificationIDs.count, 1)
+        if let createdId = store.notifications.first?.id {
+            XCTAssertEqual(localFeedbackNotificationIDs, [createdId])
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/NotificationAndMenuBarTests.swift` around lines 448 - 450, The test
should assert the exact notification identity instead of only count: capture the
notification ID returned by addNotification (or the variable that holds it) and
assert that localFeedbackNotificationIDs contains exactly that ID (or equals an
array with that single ID), while keeping the existing assertions for unread
state and deliveredNotificationIDs; refer to addNotification,
localFeedbackNotificationIDs, deliveredNotificationIDs, and
store.hasUnreadNotification (with workspace.id and terminalPanel.id) to locate
and update the assertions.
Sources/TerminalNotificationStore.swift (1)

1090-1093: Small readability tweak: drop the placeholder assignment.

You can make the unused parameter explicit in the signature and remove _ = notification.

Suggested cleanup
-    private func playSuppressedNotificationFeedback(for notification: TerminalNotification) {
-        _ = notification
+    private func playSuppressedNotificationFeedback(for _: TerminalNotification) {
         NotificationSoundSettings.playSelectedSound()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalNotificationStore.swift` around lines 1090 - 1093, The
assignment `_ = notification` is a placeholder; remove it and mark the parameter
as intentionally unused in the function signature of
playSuppressedNotificationFeedback(for:) by replacing the named parameter with
an unnamed/ignored parameter (e.g., use `_` for the parameter) so the body
simply calls NotificationSoundSettings.playSelectedSound() without the redundant
assignment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/NotificationAndMenuBarTests.swift`:
- Around line 448-450: The test should assert the exact notification identity
instead of only count: capture the notification ID returned by addNotification
(or the variable that holds it) and assert that localFeedbackNotificationIDs
contains exactly that ID (or equals an array with that single ID), while keeping
the existing assertions for unread state and deliveredNotificationIDs; refer to
addNotification, localFeedbackNotificationIDs, deliveredNotificationIDs, and
store.hasUnreadNotification (with workspace.id and terminalPanel.id) to locate
and update the assertions.

In `@Sources/TerminalNotificationStore.swift`:
- Around line 1090-1093: The assignment `_ = notification` is a placeholder;
remove it and mark the parameter as intentionally unused in the function
signature of playSuppressedNotificationFeedback(for:) by replacing the named
parameter with an unnamed/ignored parameter (e.g., use `_` for the parameter) so
the body simply calls NotificationSoundSettings.playSelectedSound() without the
redundant assignment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 804668e9-9875-4170-bfcf-ad4879338b2d

📥 Commits

Reviewing files that changed from the base of the PR and between 9844913 and dc00cf3.

📒 Files selected for processing (2)
  • Sources/TerminalNotificationStore.swift
  • cmuxTests/NotificationAndMenuBarTests.swift

@lawrencecchen
lawrencecchen merged commit c44e975 into main Mar 20, 2026
14 checks passed
@lawrencecchen
lawrencecchen deleted the task-custom-sound-notification-reliability branch March 20, 2026 08:11

This branch was successfully deployed

1 active deployment
Preview — 56c031f8 Deployed Mar 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant